Skip to content

Go 请求处理:把不可信输入变成明确结果 ​

一个请求到达 Handler 时,参数还只是客户端发来的字节。能解码不代表合法,合法不代表有权限,响应写成功也不代表客户端完整收到了。请求处理要把这些边界分开,才知道错误该在哪里被拒绝。

本文从一个只接收 count 的 JSON 接口开始,再扩展到上传、下载和 SSE。示例运行于 Go 1.27.2、Linux/amd64;这一版本已经提供 encoding/json/v2,下文会明确区分它和原来的 encoding/json,不把两者默认行为混在一起。

参数从哪里来,就从哪里明确读取 ​

来源Go 常用入口应检查什么
路径r.PathValue("id")ID 格式、范围、对象权限
Queryr.URL.Query()、Values.Get是否允许重复值、默认值、白名单
Headerr.Header.Get、mime.ParseMediaType内容类型、凭据格式、可信来源
JSON Bodyjson.Decoder 或 v2 解码函数大小、结构、未知字段、额外内容
普通表单ParseForm、PostForm来源优先级、字段重复、总量
MultipartMultipartReader 或 ParseMultipartForm总量、单文件量、字段和文件数、临时文件

绑定只是把文本转成字段,例如把 "12" 转成整数。校验还要判断整数是否在范围内;业务授权则判断当前身份能否修改目标对象。直接绑定数据库模型,容易让客户端写入本不该修改的 role、owner_id、approved 等字段。单独定义请求 DTO,把允许修改的字段逐个传入业务层,边界更清楚。

Query 的 Get 只取一个值。若接口要求某参数只能出现一次,就检查 len(r.URL.Query()["limit"]),不要悄悄忽略重复值。ParseForm 会合并不同来源的表单数据;安全相关字段要明确从哪一处读,不让查询参数和请求体互相覆盖。Request 的表单 API

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[请求字节] --> B[限制大小与读取时间]
    B --> C[检查 Content-Type]
    C --> D[解码结构]
    D --> E[字段与跨字段校验]
    E --> F[身份和对象权限检查]
    F --> G[执行业务]
    G --> H[编码响应并提交]
    B -. 超限 .-> X[明确的错误响应]
    D -. 格式错误 .-> X
    E -. 内容不合法 .-> X
    F -. 不允许 .-> X
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[请求字节] --> B[限制大小与读取时间]
    B --> C[检查 Content-Type]
    C --> D[解码结构]
    D --> E[字段与跨字段校验]
    E --> F[身份和对象权限检查]
    F --> G[执行业务]
    G --> H[编码响应并提交]
    B -. 超限 .-> X[明确的错误响应]
    D -. 格式错误 .-> X
    E -. 内容不合法 .-> X
    F -. 不允许 .-> X

实际顺序会因接口调整,例如应尽早做廉价鉴权,避免未授权用户触发大文件解析。图表达的是不同检查的职责,而不是要求所有系统固定按同一个顺序执行。

JSON 接口先解决三个问题:多大、几份、什么字段 ​

本例只接受一个 JSON 对象,count 必须显式提供且在 0 到 10 之间。开头先检查媒体类型,再限制实际读取量:

go
media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || media != "application/json" {
    http.Error(w, "JSON required", 415)
    return
}
r.Body = http.MaxBytesReader(w, r.Body, 128)

不能只检查 Content-Length:请求可能没有声明长度,声明值也不能代替实际读取边界。MaxBytesReader 会在读超限时返回 *http.MaxBytesError,可以用 errors.As 或当前版本的 errors.AsType 判断,映射为 413。MaxBytesReader

Decoder.Decode 只读一个 JSON 值。读完第一个对象后,再 Decode 一次并要求得到 io.EOF,才能拒绝尾随的第二个值。此处继续读取也使“合法对象后附加大量空白”不能绕过总大小检查。网络读取时间仍需由 Server 配置或请求策略限制,字节上限不会阻止客户端一秒只发一个字节。

本次实测:

text
{"count":0}           -> 204
{}                    -> 422
{"count":null}        -> 422
{"count":11}          -> 422
{"count":0,"extra":1} -> 400
{"count":0}{}         -> 400

表面上六个输入都“像 JSON”,失败原因却不同。未知字段由 DisallowUnknownFields 拒绝;两个连续对象被第二次 Decode 发现;缺失、null 和越界是本接口的业务约束。本文选择 422 表示内容校验失败,实际 API 可以统一使用 400,但要保持契约一致。

缺失、null、零值不是一回事 ​

定义 Count int 时,{} 和 {"count":0} 解码后都会得到 0。若“用户没填写”和“用户明确填 0”有不同含义,普通 int 不够表达。

go
type input struct {
    Count *int `json:"count"`
}

对一个新创建的结构体,本次结果是:

text
{} present=false
{"count":null} present=false
{"count":0} present=true

指针区分了缺失/空与显式 0,却没有区分缺失和 null。PATCH 场景若约定“缺失是不修改、null 是清空、数字是赋值”,需要带存在标记的自定义类型,或者先用 map[string]json.RawMessage 检查成员是否存在,再判断它是否为 null。解码前也不要随意复用带旧数据的对象:未出现的成员可能保留已有值。encoding/json.Unmarshal

omitempty 控制输出,不替你定义输入语义 ​

本次结构体包含整数零值、指向零值的指针、nil 切片和零时间。代码中的两个时间字段只差一个标签:

go
zero := 0
v := struct {
    Count   int       `json:"count,omitempty"`
    Present *int      `json:"present,omitempty"`
    Tags    []string  `json:"tags"`
    When    time.Time `json:"when,omitempty"`
    Omit    time.Time `json:"omit,omitzero"`
}{Present: &zero}
b, err := json.Marshal(v)
if err != nil { panic(err) }
fmt.Println(string(b))

v1 实测输出:

json
{"present":0,"tags":null,"when":"0001-01-01T00:00:00Z"}

其中 count int 带 omitempty 被省略;present *int 非 nil,所以保留 0;nil 切片默认是 null;time.Time 是结构体,v1 的 omitempty 不把它当空,所以零时间仍出现。给另一个时间字段加 omitzero 后,它被省略。omitzero 按零值判断,并能使用类型的 IsZero 方法。Marshal 的标签规则

相同结构体交给 v2(导入名为 jsonv2),只换编码调用:

go
b, err = jsonv2.Marshal(v)
if err != nil { panic(err) }
fmt.Println(string(b))

本次得到:

json
{"count":0,"present":0,"tags":[],"when":"0001-01-01T00:00:00Z"}

差别不只有 nil 切片。v2 的 omitempty 按 JSON 表示是否为空判断,数字 0 和 false 不属于它要省略的 JSON 空值;要按 Go 零值省略,用 omitzero。迁移时如果只改 import,不改契约测试,就可能让客户端突然收到以前不存在的字段。

行为encoding/json 默认encoding/json/v2 默认
nil slice / map 编码null空数组 / 空对象
结构体字段名称匹配接受大小写不敏感匹配区分大小写
对象重复名称接受,后值覆盖或合并,视目标类型而定拒绝
未知结构体成员忽略;可用 Decoder 拒绝忽略;可用 RejectUnknownMembers(true) 拒绝
omitempty 判断Go 中规定的一组“空值”JSON 的 null、空字符串、空数组、空对象

本次 {"n":1,"n":2} 解码到整数成员,v1 得到 2,v2 报错。不要据此认为 v1 对任何目标类型都简单覆盖:结构体、map 等有合并细节。对安全敏感接口,重复键还可能让签名端、网关与业务端看见不同解释,应统一解析策略。v1 Migrating to v2、v2 Unmarshal

Go 1.27 的 v1 API 默认由新实现支撑,但保留 v1 行为;导入 v2 才选择 v2 默认语义。不能把实现更新误读成旧接口的所有默认行为都变了。Go 1.27 发布说明

时间和大整数要在契约里说清楚 ​

time.Time 的 JSON 表示采用带时区的 RFC 3339 字符串,精度可到纳秒。它不是任意的 YYYY-MM-DD hh:mm:ss。对外接口应规定是否统一 UTC、允许的精度、是否接受偏移时区;日期“2026-10-13”与一个绝对时刻也不是同一种业务数据。Time.MarshalJSON

解码到 any 时,v1 默认把 JSON 数字变成 float64。下列差异来自同一份输入,唯一变化是解码目标:

go
in := []byte(`{"id":9007199254740993}`)
var generic map[string]any
var typed struct { ID int64 `json:"id"` }
if err := json.Unmarshal(in, &generic); err != nil { panic(err) }
if err := json.Unmarshal(in, &typed); err != nil { panic(err) }
fmt.Printf("any: %T %.0f\n", generic["id"], generic["id"])
fmt.Println("int64:", typed.ID)
text
any: float64 9007199254740992
int64: 9007199254740993

原数是 9007199254740993,第一种已经丢精度。Go 内部有固定结构时,直接解码到 int64。必须保留动态结构时,给 Decoder 调 UseNumber,得到 json.Number 后再显式转整数并检查错误。跨 JavaScript 等语言传输的长 ID,通常把它定义成字符串更稳妥;仅仅让 Go 正确解析,不能修复另一端 Number 的精度限制。RFC 8259 §6

JSON 性能先测真实形状,再考虑换实现 ​

请求体较小时,网络、数据库和权限查询可能比序列化更贵。先确定 JSON 是否真是瓶颈,再比较相同业务语义下的实现。类型化 DTO 能避免动态类型断言,也让字段约束更清楚;流式 Decoder 便于接 io.Reader,但不能据此断言它总是零分配或比 Unmarshal 快。

本次只测一个包含整数、短字符串、两元素字符串数组的小对象,每次解码到新结构体。Go 1.27.2、Linux/amd64、AMD EPYC 9V74,同一命令测三次,200ms/次:

路径三次 ns/opB/opallocs/op
v1,类型化结构体701.0 / 593.6 / 610.1963
v2,类型化结构体492.9 / 484.7 / 471.3963

这组数据说明该输入、该机器上的 v2 路径更快,不证明所有 JSON 都更快,也不代表整个接口的吞吐提升同样比例。两套 API 的默认语义存在差异,正式迁移还要验证重复键、零值、大小写及历史客户端。完整基准在文末,不使用缓存对象来掩盖真实分配。

文件上传:内存阈值不等于总大小上限 ​

ParseMultipartForm(maxMemory) 中的参数主要决定文件内容保留在内存中的量,超出部分可落临时文件;它不是整个请求的硬上限。要限制总上传量,先套 MaxBytesReader,还要限制单文件大小、文件数、字段数和处理时间。ParseMultipartForm、multipart.Reader.ReadForm

常见的两条路径:

方式适合场景必须收尾的资源
ParseMultipartForm + FormFile表单较小,需要随机访问字段和文件关闭打开的文件,MultipartForm.RemoveAll() 清临时文件
MultipartReader + NextPart大文件、逐段处理、直接转存每个 Part 及时关闭;自行控制每段和总量

客户端提交的文件名与 Content-Type 都不可信。保存时生成服务端对象名,使用固定允许目录,别把上传名直接拼到文件路径;路径清理本身也不是访问控制。按业务检查实际内容,下载时再设置受控媒体类型与 Content-Disposition。病毒扫描、图片解码的 CPU/内存消耗,同样需要预算,不能只限制原始文件字节数。

本次上传测试发送内容 hello,处理函数只计算字节数并丢弃,不保存任意路径,返回 bytes=5;它演示 RemoveAll 的收尾调用,不声称验证了某个对象存储或恶意文件扫描器。负例测试拒绝错误的 multipart 输入。

文件下载优先复用 ServeContent 的协议处理 ​

已有一个可 seek 的文件或 reader 时,http.ServeContent 能处理 Range 与部分条件请求,省去自己实现字节范围的复杂边界。示例从 hello 下载 bytes=1-3:

go
http.ServeContent(w, r, "fixed.txt", time.Time{}, strings.NewReader("hello"))

实际输出:

text
206 ell bytes 1-3/5

路径对应到哪个文件、用户有没有权限、是否允许目录访问,仍然由应用决定。ServeContent 接受的是已选择的数据源,不会替你检查订单归属。ServeFile 和 FileServer 的根目录也必须审视,别把包含配置、备份、密钥的目录直接暴露出去。ServeContent

SSE:一次 HTTP 响应里分批发事件 ​

SSE 适合服务端向浏览器持续推送进度、通知或生成结果。它是服务端到客户端的单向事件流;浏览器向服务端发命令仍可用另一个 HTTP 请求。响应媒体类型是 text/event-stream,内容为 UTF-8 文本,空行结束一个事件。HTML 标准:Server-sent events

text
id: 1
event: progress
data: {"percent":25}

data: 可以出现多行;id: 帮助重连时恢复位置;retry: 可以指定重连等待时间;以冒号开头的注释常用作心跳。服务端仍需保留可重放记录并处理 Last-Event-ID,单独发送 ID 不会自动提供可靠重放或去重。

写进缓冲区,不等于客户端已经收到 ​

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    A[业务产生事件] --> B[格式化 SSE 文本]
    B --> C[压缩或应用缓冲]
    C --> D[Go HTTP 缓冲]
    D --> E[反向代理或 CDN 缓冲]
    E --> F[浏览器 EventSource]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    A[业务产生事件] --> B[格式化 SSE 文本]
    B --> C[压缩或应用缓冲]
    C --> D[Go HTTP 缓冲]
    D --> E[反向代理或 CDN 缓冲]
    E --> F[浏览器 EventSource]

每一层都可能攒数据。Go 侧写完一个事件后调用 http.NewResponseController(w).Flush(),才把当前可刷出的数据推进下层;它不保证代理也立刻转发,更不证明浏览器已经处理。代理需要单独关闭流式路径的缓冲、设置足够的空闲超时;本文没在真实代理上验证这些配置。

本次增量测试特意让 Handler 发出第一条后停在 channel 上,客户端读到完整事件后才释放它,因此不是“等 Handler 返回后一次读完”冒充流式验证。Handler 中决定顺序的是下面几行:

go
if _, err := fmt.Fprint(w, "id: 1\ndata: ready\n\n"); err != nil { return }
if err := http.NewResponseController(w).Flush(); err != nil { return }
select {
case <-release: // 客户端读到第一条后,测试才允许继续
case <-r.Context().Done():
    return
}
<-r.Context().Done()

测试输出:

text
first event arrived while handler remained active
handler observed client cancellation

随后取消客户端 Context,服务端从 r.Context().Done() 得知并退出。每次写前可以设置新的写 deadline,防止慢客户端把写操作无限拖住;也要检查 Write 和 Flush 的错误。短的全局 Client.Timeout 会覆盖整条流,不能直接照搬普通 API 调用的设置。

中间件必须保留流式能力 ​

ResponseWriter 接口本身没有 Flush。包装器只嵌入 http.ResponseWriter,会隐藏底层额外的方法。当前 Go 可以通过 ResponseController 找 FlushError、Flush,或沿包装器的 Unwrap() http.ResponseWriter 找到底层;不支持时会返回 http.ErrNotSupported。ResponseController

Unwrap 只提供访问底层的路径。如果包装器自己缓存或压缩内容,必须先刷它自己的缓冲,再向下 Flush。否则控制器跳到底层刷了一次,真正的数据仍在上层。鉴权、日志、超时、压缩都要对流式路由单独验收。

响应头一旦提交,后面失败就不能改成 HTTP 500。可以按既定协议发送 event: error 和受控 JSON 内容,然后关闭流;若网络写失败,连错误事件也发不出去,只能记录服务端日志,并让客户端把未出现的“完成事件”识别为中断。不要在 SSE 后面追加普通 http.Error 文本。

资料与验证范围 ​

go test、go test -race、go vet 均通过;另执行三次微基准。测试包括大小超限、缺失/null、范围、未知字段、尾随 JSON、JSON 默认差异、multipart、Range、实际 TCP 上的 SSE 增量与取消。没有运行浏览器 EventSource、反向代理、对象存储或跨语言客户端。

完整复现代码 ​

文件放在同一目录,使用 Go 1.27.2:

sh
go test -v -count=1 ./...
go test -race -count=1 ./...
go vet ./...
go test -run '^$' -bench . -benchmem -benchtime=200ms -count=3

go.mod ​

go.mod
module example.com/webapi/03_request

go 1.27.2

request_test.go ​

代码块 · 180 行 · 点击收起 / 展开
go
package requestdemo

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

type input struct {
	Count *int `json:"count"`
}

func jsonHandler(w http.ResponseWriter, r *http.Request) {
	media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || media != "application/json" {
		http.Error(w, "JSON required", 415)
		return
	}
	r.Body = http.MaxBytesReader(w, r.Body, 128)
	defer r.Body.Close()
	d := json.NewDecoder(r.Body)
	d.DisallowUnknownFields()
	var v input
	err = d.Decode(&v)
	if err == nil {
		var extra any
		err = d.Decode(&extra)
		if err == io.EOF {
			err = nil
		} else if err == nil {
			err = errors.New("multiple JSON values")
		}
	}
	if err != nil {
		var tooLarge *http.MaxBytesError
		if errors.As(err, &tooLarge) {
			http.Error(w, "too large", 413)
		} else {
			http.Error(w, "invalid JSON", 400)
		}
		return
	}
	if v.Count == nil || *v.Count < 0 || *v.Count > 10 {
		http.Error(w, "count must be 0..10", 422)
		return
	}
	w.WriteHeader(204)
}
func upload(w http.ResponseWriter, r *http.Request) {
	r.Body = http.MaxBytesReader(w, r.Body, 1024)
	defer r.Body.Close()
	err := r.ParseMultipartForm(64)
	if r.MultipartForm != nil {
		defer r.MultipartForm.RemoveAll()
	}
	if err != nil {
		http.Error(w, "bad multipart", 400)
		return
	}
	f, _, err := r.FormFile("file")
	if err != nil {
		http.Error(w, "file required", 400)
		return
	}
	defer f.Close()
	n, err := io.Copy(io.Discard, f)
	if err != nil {
		http.Error(w, "read failed", 400)
		return
	}
	_, _ = fmt.Fprintf(w, "bytes=%d", n)
}
func events(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	ctl := http.NewResponseController(w)
	for i := 1; i <= 2; i++ {
		select {
		case <-r.Context().Done():
			return
		default:
		}
		if err := ctl.SetWriteDeadline(time.Now().Add(time.Second)); err != nil {
			return
		}
		if _, err := fmt.Fprintf(w, "id: %d\ndata: %d\n\n", i, i); err != nil {
			return
		}
		if err := ctl.Flush(); err != nil {
			return
		}
	}
}
func TestJSONBoundary(t *testing.T) {
	for _, tc := range []struct {
		body string
		want int
	}{
		{`{"count":0}`, 204}, {`{}`, 422}, {`{"count":null}`, 422},
		{`{"count":11}`, 422}, {`{"count":0,"extra":1}`, 400},
		{`{"count":0}{}`, 400}, {`{"count":0}` + strings.Repeat(" ", 128), 413},
	} {
		r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
		r.Header.Set("Content-Type", "application/json")
		w := httptest.NewRecorder()
		jsonHandler(w, r)
		if w.Code != tc.want {
			t.Errorf("body=%q got=%d want=%d", tc.body, w.Code, tc.want)
		}
	}
	var value map[string]any
	d := json.NewDecoder(strings.NewReader(`{"id":9007199254740993}`))
	d.UseNumber()
	if err := d.Decode(&value); err != nil {
		t.Fatal(err)
	}
	if value["id"].(json.Number).String() != "9007199254740993" {
		t.Fatal(value)
	}
	zero := 0
	b, err := json.Marshal(struct {
		A int  `json:"a,omitempty"`
		B *int `json:"b,omitempty"`
	}{B: &zero})
	if err != nil || string(b) != `{"b":0}` {
		t.Fatalf("%s %v", b, err)
	}
	t.Log("size, missing/null, range, unknown fields, trailing JSON, omitempty, and UseNumber verified")
}
func TestUploadDownloadAndSSE(t *testing.T) {
	var body bytes.Buffer
	mw := multipart.NewWriter(&body)
	f, err := mw.CreateFormFile("file", "untrusted.txt")
	if err != nil {
		t.Fatal(err)
	}
	if _, err = io.WriteString(f, "hello"); err != nil {
		t.Fatal(err)
	}
	if err = mw.Close(); err != nil {
		t.Fatal(err)
	}
	r := httptest.NewRequest("POST", "/upload", &body)
	r.Header.Set("Content-Type", mw.FormDataContentType())
	w := httptest.NewRecorder()
	upload(w, r)
	if w.Body.String() != "bytes=5" {
		t.Fatal(w.Body.String())
	}
	r = httptest.NewRequest("GET", "/download", nil)
	r.Header.Set("Range", "bytes=1-3")
	w = httptest.NewRecorder()
	http.ServeContent(w, r, "fixed.txt", time.Time{}, strings.NewReader("hello"))
	if w.Code != 206 || w.Body.String() != "ell" {
		t.Fatalf("%d %s", w.Code, w.Body.String())
	}
	s := httptest.NewServer(http.HandlerFunc(events))
	defer s.Close()
	c := &http.Client{Timeout: 2 * time.Second}
	defer c.CloseIdleConnections()
	resp, err := c.Get(s.URL)
	if err != nil {
		t.Fatal(err)
	}
	b, err := io.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil || string(b) != "id: 1\ndata: 1\n\nid: 2\ndata: 2\n\n" {
		t.Fatalf("%q %v", b, err)
	}
	t.Log("multipart cleanup path, range response, and finite flushed SSE stream verified")
}

teaching_test.go ​

代码块 · 203 行 · 点击收起 / 展开
go
package requestdemo

import (
	"bufio"
	"context"
	"encoding/json"
	jsonv2 "encoding/json/v2"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

func TestJSONTeaching(t *testing.T) {
	zero := 0
	v := struct {
		Count   int       `json:"count,omitempty"`
		Present *int      `json:"present,omitempty"`
		Tags    []string  `json:"tags"`
		When    time.Time `json:"when,omitempty"`
		Omit    time.Time `json:"omit,omitzero"`
	}{Present: &zero}
	b, err := json.Marshal(v)
	if err != nil {
		t.Fatal(err)
	}
	t.Log("v1:", string(b))
	b, err = jsonv2.Marshal(v)
	if err != nil {
		t.Fatal(err)
	}
	t.Log("v2:", string(b))
	for _, in := range []string{`{}`, `{"count":null}`, `{"count":0}`} {
		var x input
		if err := json.Unmarshal([]byte(in), &x); err != nil {
			t.Fatal(err)
		}
		t.Logf("%s present=%v", in, x.Count != nil)
	}
	var generic map[string]any
	if err := json.Unmarshal([]byte(`{"id":9007199254740993}`), &generic); err != nil {
		t.Fatal(err)
	}
	t.Logf("any: %T %.0f", generic["id"], generic["id"])
	var typed struct {
		ID int64 `json:"id"`
	}
	if err := json.Unmarshal([]byte(`{"id":9007199254740993}`), &typed); err != nil {
		t.Fatal(err)
	}
	t.Log("int64:", typed.ID)
	var duplicate struct {
		N int `json:"n"`
	}
	if err := json.Unmarshal([]byte(`{"n":1,"n":2}`), &duplicate); err != nil {
		t.Fatal(err)
	}
	t.Log("v1 duplicate:", duplicate.N)
	err = jsonv2.Unmarshal([]byte(`{"n":1,"n":2}`), &duplicate)
	t.Log("v2 duplicate rejected:", err != nil)
	if err == nil {
		t.Fatal("duplicate must be rejected")
	}
}

func TestIncrementalSSEAndCancel(t *testing.T) {
	release := make(chan struct{})
	stopped := make(chan struct{})
	s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer close(stopped)
		w.Header().Set("Content-Type", "text/event-stream")
		ctl := http.NewResponseController(w)
		if err := ctl.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
			return
		}
		if _, err := fmt.Fprint(w, "id: 1\ndata: ready\n\n"); err != nil {
			return
		}
		if err := ctl.Flush(); err != nil {
			return
		}
		select {
		case <-release:
		case <-r.Context().Done():
			return
		}
		<-r.Context().Done()
	}))
	defer s.Close()
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, "GET", s.URL, nil)
	if err != nil {
		t.Fatal(err)
	}
	c := &http.Client{}
	defer c.CloseIdleConnections()
	resp, err := c.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	scanner := bufio.NewScanner(resp.Body)
	var lines []string
	for scanner.Scan() {
		if scanner.Text() == "" {
			break
		}
		lines = append(lines, scanner.Text())
	}
	if err := scanner.Err(); err != nil {
		t.Fatal(err)
	}
	if strings.Join(lines, "|") != "id: 1|data: ready" {
		t.Fatal(lines)
	}
	select {
	case <-stopped:
		t.Fatal("handler ended before release")
	default:
	}
	t.Log("first event arrived while handler remained active")
	close(release)
	cancel()
	select {
	case <-stopped:
		t.Log("handler observed client cancellation")
	case <-time.After(2 * time.Second):
		t.Fatal("cancel not observed")
	}
}

func TestBoundaryStatusOutput(t *testing.T) {
	for _, body := range []string{`{"count":0}`, `{}`, `{"count":null}`, `{"count":11}`, `{"count":0,"extra":1}`, `{"count":0}{}`} {
		w := httptest.NewRecorder()
		r := httptest.NewRequest("POST", "/", strings.NewReader(body))
		r.Header.Set("Content-Type", "application/json")
		jsonHandler(w, r)
		t.Logf("%s -> %d", body, w.Code)
	}
	r := httptest.NewRequest("POST", "/", strings.NewReader(`{"count":0}`))
	r.Header.Set("Content-Type", "text/plain")
	w := httptest.NewRecorder()
	jsonHandler(w, r)
	if w.Code != 415 {
		t.Fatal(w.Code)
	}
	r = httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("x", 2048)))
	r.Header.Set("Content-Type", "multipart/form-data; boundary=demo")
	w = httptest.NewRecorder()
	upload(w, r)
	if w.Code != 400 {
		t.Fatal(w.Code)
	}
	t.Log("wrong media type=415; malformed/oversized upload rejected")
}

var jsonInput = []byte(`{"id":42,"name":"gopher","tags":["go","http"]}`)

type payload struct {
	ID   int      `json:"id"`
	Name string   `json:"name"`
	Tags []string `json:"tags"`
}

var sink payload

func BenchmarkJSON(b *testing.B) {
	b.Run("v1-typed", func(b *testing.B) {
		for b.Loop() {
			var x payload
			if err := json.Unmarshal(jsonInput, &x); err != nil {
				b.Fatal(err)
			}
			sink = x
		}
	})
	b.Run("v2-typed", func(b *testing.B) {
		for b.Loop() {
			var x payload
			if err := jsonv2.Unmarshal(jsonInput, &x); err != nil {
				b.Fatal(err)
			}
			sink = x
		}
	})
}

func Example_serveContent() {
	r := httptest.NewRequest("GET", "/download", nil)
	r.Header.Set("Range", "bytes=1-3")
	w := httptest.NewRecorder()
	http.ServeContent(w, r, "fixed.txt", time.Time{}, strings.NewReader("hello"))
	res := w.Result()
	defer res.Body.Close()
	body, _ := io.ReadAll(res.Body)
	fmt.Println(res.StatusCode, string(body), res.Header.Get("Content-Range"))
	// Output:
	// 206 ell bytes 1-3/5
}

保留各书来源与许可 · 示例运行条件以正文为准